SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
17.4 KB · 234 lines tsx
Raw Blame History
1import Link from 'next/link';2import { notFound } from 'next/navigation';3import { requireAdmin } from '@/lib/admin/auth';4import { connectorDetail } from '@/lib/admin/queries';5import { connectorAction, updateConnectorConfig } from '@/lib/admin/actions';6import { AdminShell, ActionButton, StatusPill, Kpi, fmtTs, n } from '@/components/admin/shell';7import { HealthLabel, SourceLogo, Progress, Chip, Json } from '@/components/admin/connector-bits';8import { Table, th, td, tdNum } from '@/components/ui/primitives';910export default async function ConnectorInspectPage({ params }: { params: Promise<{ id: string }> }) {11  await requireAdmin();12  const { id } = await params;13  const d = await connectorDetail(id);14  if (!d) notFound();15  const c = d.connector;16  const health = (d.health?.health ?? {}) as Record<string, unknown>;17  const outputs = (d.outputs ?? {}) as Record<string, unknown>;18  const meta = (c.meta ?? {}) as Record<string, unknown>;19  const activeBackfill = d.backfills.find((b) => b.status === 'running' || b.status === 'paused');20  const fieldStats = d.fieldStats as Array<{ field: string; day: string; total: number; nulls: number }>;21  const today = new Date().toISOString().slice(0, 10);22  const nullRates = Object.values(23    fieldStats.reduce<Record<string, { field: string; today: [number, number]; base: [number, number] }>>((acc, r) => {24      const e = (acc[r.field] ??= { field: r.field, today: [0, 0], base: [0, 0] });25      const t = r.day === today ? e.today : e.base;26      t[0] += Number(r.total);27      t[1] += Number(r.nulls);28      return acc;29    }, {}),30  );31  const actions = [32    ['probe', 'Test', 'neutral'],33    ['run', 'Run now', 'neutral'],34    ['backfill', activeBackfill ? 'Continue backfill' : 'Backfill', 'neutral'],35    ...(activeBackfill?.status === 'running' ? [['backfill_pause', 'Pause backfill', 'neutral']] : []),36    ...(activeBackfill ? [['backfill_reset', 'Reset backfill', 'danger']] : []),37    ['retry', 'Retry from cursor', 'neutral'],38    c.status === 'active' ? ['pause', 'Pause', 'danger'] : ['resume', 'Resume', 'primary'],39    c.status === 'maintenance' ? ['resume', 'End maintenance', 'primary'] : ['maintenance', 'Maintenance', 'neutral'],40  ] as const;41  return (42    <AdminShell43      current="/admin/connectors"44      title={String(c.display_name)}45      subtitle={46        <>47          <SourceLogo domain={String(meta.domain ?? '')} name={String(c.display_name)} /> {String(c.id)} · source <span className="font-mono">{String(c.source_id)}</span> · {String(meta.domain ?? '')} · {String(meta.country ?? (c.regions as string[])?.[0] ?? 'global')} · {(c.engine_priority as string[]).join(' → ')} · {String(meta.acquisitionMethod ?? '')} · categories {(c.categories as string[]).join(', ')} · <Link href="/admin/connectors" className="underline">back</Link>48        </>49      }50      actions={51        <div className="flex flex-wrap gap-1">52          {actions.map(([a, label, tone]) => (53            <form key={a} action={connectorAction}>54              <input type="hidden" name="id" value={String(c.id)} />55              <input type="hidden" name="action" value={a} />56              <ActionButton label={label} small={false} tone={tone as 'neutral' | 'danger' | 'primary'} />57            </form>58          ))}59        </div>60      }61    >62      <div className="mb-3 flex flex-wrap gap-1">63        {((meta.capabilities as string[]) ?? []).map((cap) => (64          <Chip key={cap} tone="index">{cap}</Chip>65        ))}66        {((meta.requires as string[]) ?? []).map((r) => (67          <Chip key={r} tone="alert">requires {r}</Chip>68        ))}69        <Chip>refresh {String(meta.refreshClass ?? '')} · {n(c.refresh_frequency_minutes)} min</Chip>70        <Chip>history {String(meta.historicalDepth ?? 'none')}</Chip>71        {meta.termsUrl ? (72          <a href={String(meta.termsUrl)} target="_blank" rel="noreferrer noopener" className="text-[10px] underline text-subtle">terms</a>73        ) : null}74      </div>75      <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">76        <Kpi label="Status" value={<StatusPill status={String(c.status)} />} sub={<>health <HealthLabel status={(d.health?.status as string) ?? 'unknown'} /></>} />77        <Kpi label="Success 24h" value={health.success_rate_24h != null ? `${Math.round(Number(health.success_rate_24h) * 100)}%` : '—'} sub={`${n(health.pages_success)}/${n(health.pages_attempted)} pages`} />78        <Kpi label="Firecrawl / Scrapfly" value={`${health.firecrawl_success_rate != null ? Math.round(Number(health.firecrawl_success_rate) * 100) + '%' : '—'} / ${health.scrapfly_fallback_rate != null ? Math.round(Number(health.scrapfly_fallback_rate) * 100) + '%' : '—'}`} sub="success / fallback share" />79        <Kpi label="Parse failures" value={health.parse_failure_rate != null ? `${(Number(health.parse_failure_rate) * 100).toFixed(1)}%` : '—'} sub={`${n(health.duplicates_24h)} duplicates 24h`} />80        <Kpi label="Outputs" value={`${n(outputs.sales)} sales`} sub={`${n(outputs.listings)} listings · ${n(outputs.observations)} observations`} />81        <Kpi label="Last success" value={fmtTs(c.last_success_at).slice(0, 16)} sub={`next ${fmtTs(c.next_run_at).slice(0, 16)} · fresh ${health.data_freshness ? fmtTs(health.data_freshness).slice(0, 10) : '—'}`} />82        <Kpi label="Requests 24h" value={n(health.requests_24h)} sub={`${health.latency_ms_avg != null ? n(health.latency_ms_avg) + ' ms avg' : '—'} · ${n(health.challenges_24h)} challenges · ${n(health.rate_limited_24h)}× 429`} tone={Number(health.challenges_24h) > 0 ? 'alert' : undefined} />83        <Kpi label="HTTP errors 24h" value={Object.entries((health.http_errors as Record<string, number>) ?? {}).map(([k, v]) => `${k}×${v}`).join(' ') || '0'} sub={`${n(health.circuit_refusals_24h)} refused by circuit`} tone={Object.keys((health.http_errors as Record<string, number>) ?? {}).length ? 'loss' : undefined} />84        <Kpi label="Volume baseline" value={health.records_7d_daily_avg != null ? `${n(health.records_7d_daily_avg)}/day` : '—'} sub={`${n(health.records_24h)} raw in 24h`} tone={((health.anomalies as string[]) ?? []).some((a) => a.startsWith('result_count_collapse')) ? 'alert' : undefined} />85        <Kpi label="Certificates" value={n(outputs.certificates)} sub="cert numbers seen via this source" />86      </div>8788      {activeBackfill || d.backfills.length ? (89        <section className="card mt-6">90          <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Historical backfill campaigns</h2></header>91          <Table>92            <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Progress</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Items</th><th className={th}>Reached date</th><th className={`${th} text-right`}>Runs</th><th className={`${th} text-right`}>Errors</th><th className={th}>Last cursor</th><th className={th}>Last error</th></tr></thead>93            <tbody>94              {d.backfills.map((b) => (95                <tr key={String(b.id)}>96                  <td className={td}>{fmtTs(b.started_at)}</td>97                  <td className={td}><StatusPill status={String(b.status)} /></td>98                  <td className={td}><Progress percent={b.percent as number | null} status={String(b.status)} /></td>99                  <td className={tdNum}>{n(b.pages_processed)}{b.total_pages ? ` / ${n(b.total_pages)}` : ''}</td>100                  <td className={tdNum}>{n(b.items_processed)}</td>101                  <td className={td}>{b.reached_date ? String(b.reached_date) : '—'}</td>102                  <td className={tdNum}>{n(b.runs)}</td>103                  <td className={tdNum}>{n(b.errors)}</td>104                  <td className={`${td} max-w-[260px] truncate font-mono text-[10px]`} title={JSON.stringify(b.last_cursor)}>{JSON.stringify(b.last_cursor)}</td>105                  <td className={`${td} max-w-[220px] truncate text-loss`} title={String(b.last_error ?? '')}>{String(b.last_error ?? '')}</td>106                </tr>107              ))}108            </tbody>109          </Table>110        </section>111      ) : null}112113      {nullRates.length ? (114        <section className="card mt-6">115          <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Field presence (schema-drift monitor)</h2></header>116          <div className="grid grid-cols-2 gap-x-6 gap-y-1 px-4 py-3 text-xs md:grid-cols-4 xl:grid-cols-6">117            {nullRates.map((f) => {118              const tRate = f.today[0] ? f.today[1] / f.today[0] : null;119              const bRate = f.base[0] ? f.base[1] / f.base[0] : null;120              const drift = tRate !== null && bRate !== null && tRate - bRate >= 0.3;121              return (122                <div key={f.field} className={drift ? 'text-alert' : ''}>123                  <span className="font-mono">{f.field}</span> <span className="text-subtle">null today {tRate === null ? '—' : `${Math.round(tRate * 100)}%`} · 7d {bRate === null ? '—' : `${Math.round(bRate * 100)}%`}</span>124                </div>125              );126            })}127          </div>128        </section>129      ) : null}130131      {d.anomalies.length ? (132        <section className="card mt-6">133          <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold text-alert">Anomalies (recent runs)</h2></header>134          <ul className="divide-y divide-border px-4 text-xs">{d.anomalies.map((a, i) => <li key={i} className="py-1.5 font-mono">{a}</li>)}</ul>135        </section>136      ) : null}137138      <section className="card mt-6">139        <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Runs</h2></header>140        <Table>141          <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Trigger</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Raw</th><th className={`${th} text-right`}>Norm.</th><th className={`${th} text-right`}>Dup.</th><th className={`${th} text-right`}>Rej.</th><th className={`${th} text-right`}>Credits</th><th className={th}>Engines</th><th className={th}>Error</th></tr></thead>142          <tbody>143            {d.runs.length === 0 ? <tr><td className={td} colSpan={11}><span className="text-subtle">No runs yet.</span></td></tr> : null}144            {d.runs.map((r) => (145              <tr key={String(r.id)}>146                <td className={td}>{fmtTs(r.started_at)}<div className="text-[10px] text-subtle">{r.finished_at ? `${Math.round((new Date(String(r.finished_at)).getTime() - new Date(String(r.started_at)).getTime()) / 1000)} s` : 'running'}</div></td>147                <td className={td}><StatusPill status={String(r.status)} /></td>148                <td className={td}>{String(r.trigger)}</td>149                <td className={tdNum}>{n(r.pages_success)}/{n(r.pages_attempted)}</td>150                <td className={tdNum}>{n(r.records_raw)}</td>151                <td className={tdNum}>{n(r.records_normalized)}</td>152                <td className={tdNum}>{n(r.records_duplicate)}</td>153                <td className={tdNum}>{n(r.records_rejected)}</td>154                <td className={tdNum}>{n(r.cost_credits, 1)}</td>155                <td className={`${td} font-mono text-[10px]`}>{Object.entries((r.engine_stats ?? {}) as Record<string, { attempts: number; success: number }>).map(([k, v]) => `${k} ${v.success}/${v.attempts}`).join(' · ')}</td>156                <td className={`${td} max-w-[260px] truncate text-loss`} title={String(r.error ?? '')}>{r.error ? String(r.error) : ''}</td>157              </tr>158            ))}159          </tbody>160        </Table>161      </section>162163      <div className="mt-6 grid gap-6 lg:grid-cols-2">164        <section className="card">165          <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Raw records (latest)</h2></header>166          <ul className="divide-y divide-border text-xs">167            {d.rawSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null}168            {d.rawSample.map((r) => (169              <li key={String(r.id)} className="px-4 py-2">170                <div className="flex items-center justify-between gap-2"><span className="font-mono text-[11px]">{String(r.kind)} · {String(r.engine)} · {String(r.http_status ?? '')}</span><span className="text-subtle">{fmtTs(r.fetched_at)}</span></div>171                <a href={String(r.url)} target="_blank" rel="noreferrer noopener" className="block truncate text-muted hover:text-fg">{String(r.url)}</a>172                <div className="flex items-center justify-between">173                  {r.process_error ? <p className="text-loss">{String(r.process_error)}</p> : <p className="text-[10px] text-subtle">{r.processed_at ? `processed ${fmtTs(r.processed_at)}` : 'unprocessed'}</p>}174                  <Link href={`/admin/connectors/${String(c.id)}/raw/${String(r.id)}`} className="text-[10px] underline">view payload</Link>175                </div>176              </li>177            ))}178          </ul>179        </section>180        <section className="card">181          <header className="flex items-center justify-between border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Normalized records (latest)</h2><span className="text-[11px] text-muted">{d.normalized.map((x) => `${x.status} ${n(x.n)}`).join(' · ')}</span></header>182          <ul className="divide-y divide-border text-xs">183            {d.normalizedSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null}184            {d.normalizedSample.map((r) => (185              <li key={String(r.id)} className="px-4 py-2">186                <div className="flex items-center justify-between gap-2"><span className="truncate">{String(r.raw_title ?? '')}</span><StatusPill status={String(r.status)} /></div>187                <div className="flex items-center justify-between">188                  <p className="text-[10px] text-subtle">{String(r.kind)} · {r.price ? `${String(r.price)} ${String(r.currency ?? '')}` : ''} · {r.match_method ? `${String(r.match_method)} ${r.match_confidence ? Math.round(Number(r.match_confidence) * 100) + '%' : ''}` : ''} {r.reject_reason ? `· ${String(r.reject_reason)}` : ''}</p>189                  <Link href={`/admin/connectors/${String(c.id)}/record/${String(r.id)}`} className="text-[10px] underline">view record</Link>190                </div>191              </li>192            ))}193          </ul>194        </section>195      </div>196197      <div className="mt-6 grid gap-6 lg:grid-cols-2">198        <section className="card">199          <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Costs (30d)</h2></header>200          <Table>201            <thead><tr><th className={th}>Day</th><th className={th}>Kind</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD est.</th></tr></thead>202            <tbody>203              {d.costs.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No cost events.</span></td></tr> : null}204              {d.costs.map((r, i) => <tr key={i}><td className={td}>{String(r.day)}</td><td className={td}>{String(r.kind)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td></tr>)}205            </tbody>206          </Table>207        </section>208        <section className="card">209          <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Mapping & schedule</h2></header>210          <form action={updateConnectorConfig} className="space-y-3 p-4 text-xs">211            <input type="hidden" name="id" value={String(c.id)} />212            <div className="grid grid-cols-2 gap-3">213              <label className="block text-muted">Refresh (minutes)<input name="refresh" type="number" min={5} defaultValue={Number(c.refresh_frequency_minutes)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label>214              <label className="block text-muted">Priority215                <select name="priority" defaultValue={String(c.priority)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm"><option value="high">high</option><option value="medium">medium</option><option value="low">low</option></select>216              </label>217            </div>218            <label className="block text-muted">Config JSON (seeds, mappings, selectors — connector-specific)219              <textarea name="config" rows={10} defaultValue={JSON.stringify(c.config ?? {}, null, 2)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 font-mono text-[11px]" />220            </label>221            <div className="flex items-center justify-between"><span className="text-subtle">schema v{String(c.schema_version)} · connector v{String(c.connector_version)} · source trust {d.source ? Math.round(Number(d.source.trust_score) * 100) + '%' : '—'}</span><ActionButton label="Save mapping" small={false} tone="primary" /></div>222          </form>223        </section>224      </div>225226      <section className="card mt-6">227        <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Access & compliance notes (meta.json)</h2></header>228        <div className="px-4 py-3 text-xs leading-relaxed text-muted">{String(meta.accessNotes ?? 'No accessNotes recorded — add them to meta.json (SPEC §36).')}</div>229        <details className="border-t border-border px-4 py-2 text-xs"><summary className="cursor-pointer text-subtle">registry entry</summary><Json value={meta} className="mt-2" /></details>230      </section>231    </AdminShell>232  );233}234